Skip to content

fix(runtime-dom): handle activeElement check in Shadow DOM for v-model#14196

Merged
edison1105 merged 6 commits intovuejs:mainfrom
LordSalmon:lordsalmon/fix/v-model-trim-behaviour-in-shadow-dom
Mar 25, 2026
Merged

fix(runtime-dom): handle activeElement check in Shadow DOM for v-model#14196
edison1105 merged 6 commits intovuejs:mainfrom
LordSalmon:lordsalmon/fix/v-model-trim-behaviour-in-shadow-dom

Conversation

@LordSalmon
Copy link
Copy Markdown
Contributor

@LordSalmon LordSalmon commented Dec 12, 2025

Cheers everyone

Issue:

The issue is, that using v-model.trim in the shadow dom does not behave the same as in the light dom. When an input should get trimmed in the light dom, the ref value itself gets trimmed correctly, but the displayed characters in the input won't get trimmed until you refocus the input. I like that approach since it reduces noise for the cursor and doesn't seem glitchy. However, when trimming in the shadow dom, the displayed characters (not equivalent to the value property of the input element at that moment!) are trimmed, which increases visual noise.

A demo with reproduction steps can be seen here: https://vue-3-v-model-trim-misbehaviour.vercel.app/
With the repository: https://github.com/LordSalmon/vue-3-v-model-trim-misbehaviour

The problem seems to be that line which compares the currently focused element to the activeElement of the document. However, when the input is placed inside the shadow dom, document.activeElement returns the shadow dom root node instead of the input.

Fix idea:

Initial Idea: - Checking at that point of execution whether document.activeElement is the root node for a shadow dom and if so, return the activeElement of the shadow dom.

Improvement: To support nested shadow doms, el.getRootNode() is used to directly verify the activeElement property from that root instead of starting at the root of the root document itself.

Tests:

I tried to create a test for that, but it seems as if js-dom's focus api is not entirely consistent. Reproducing the step where the displayed characters in the input still contained the spaces but were gone on refocus was not possible. But If someone has a flash of inspiration, let me know or feel free to contribute.

Summary by CodeRabbit

  • Bug Fixes

    • v-model focus detection now respects Shadow DOM boundaries and excludes range inputs, preserving lazy/trim/number behaviors; model trimming no longer incorrectly blocked by shadow roots.
    • No changes to public APIs or exported signatures.
  • Tests

    • Added a test verifying v-model trimming and focus behavior inside nested shadow roots.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Dec 12, 2025

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3f434472-be7d-4994-a9dd-3e5c34ac14e5

📥 Commits

Reviewing files that changed from the base of the PR and between acebcdd and 8d4ba6f.

📒 Files selected for processing (1)
  • packages/runtime-dom/__tests__/directives/vModel.spec.ts

📝 Walkthrough

Walkthrough

Replace the direct document.activeElement === el check in vModelText.beforeUpdate with a root-aware focus resolution using el.getRootNode(), only treating focus when rootNode is a Document or ShadowRoot and rootNode.activeElement === el, excluding el.type === 'range'. No exported signatures changed.

Changes

Cohort / File(s) Summary
vModel focus handling
packages/runtime-dom/src/directives/vModel.ts
Replace document.activeElement === el with el.getRootNode()-based check; require rootNode be Document or ShadowRoot, ensure rootNode.activeElement === el, and keep el.type !== 'range'; preserve existing lazy/trim/number and early-return logic.
Tests: shadow DOM v-model
packages/runtime-dom/__tests__/directives/vModel.spec.ts
Add test rendering an input with { trim: true } inside nested shadow roots, assert focus propagation and that model receives trimmed value while DOM value remains untrimmed; cleanup host/unrender performed.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

ready to merge

Suggested reviewers

  • KazariEX
  • baiwusanyu-c

Poem

🐰 I peeked beneath the shadowed dome,
Where focused leaves had lost their home,
I check the root and call it true,
So inputs know just what to do,
A tiny hop, a tidy roam.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: fixing the activeElement check in Shadow DOM for the v-model directive, which is the core issue addressed in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/runtime-dom/src/directives/vModel.ts (1)

54-58: Harden activeElement() for null inner activeElement + nested shadow roots

Right now, if document.activeElement.shadowRoot exists but shadowRoot.activeElement is null, the helper returns null instead of falling back to the document’s active element. Also, nested shadow roots won’t be fully resolved.

Consider:

-function activeElement() {
-  return document.activeElement && document.activeElement.shadowRoot
-    ? document.activeElement.shadowRoot.activeElement
-    : document.activeElement
-}
+function activeElement(): Element | null {
+  let el: Element | null = document.activeElement
+  while (el && el.shadowRoot?.activeElement) {
+    el = el.shadowRoot.activeElement
+  }
+  return el
+}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f51d3e2 and 287ae18.

📒 Files selected for processing (1)
  • packages/runtime-dom/src/directives/vModel.ts (2 hunks)
🔇 Additional comments (1)
packages/runtime-dom/src/directives/vModel.ts (1)

111-119: Good focus detection fix for Shadow DOM; please add a regression test (likely e2e)

Using activeElement() here matches the intended “don’t clobber the user’s in-progress text while focused” behavior in shadow trees. The remaining risk is regression without coverage, and jsdom focus behavior is known to be flaky for shadow DOM.

The current approach grants support for nested shadow doms
@LordSalmon
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Dec 14, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Dec 15, 2025

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 105 kB (+63 B) 39.7 kB (+22 B) 35.7 kB (+27 B)
vue.global.prod.js 163 kB (+63 B) 59.7 kB (+21 B) 53.1 kB (+69 B)

Usages

Name Size Gzip Brotli
createApp (CAPI only) 48.2 kB 18.7 kB 17.2 kB
createApp 56.3 kB 21.8 kB 19.9 kB
createSSRApp 60.6 kB 23.5 kB 21.5 kB
defineCustomElement 62.5 kB 23.7 kB 21.6 kB
overall 70.7 kB 27.1 kB 24.7 kB

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Dec 15, 2025

Open in StackBlitz

@vue/compiler-core

pnpm add https://pkg.pr.new/@vue/compiler-core@14196
npm i https://pkg.pr.new/@vue/compiler-core@14196
yarn add https://pkg.pr.new/@vue/compiler-core@14196.tgz

@vue/compiler-dom

pnpm add https://pkg.pr.new/@vue/compiler-dom@14196
npm i https://pkg.pr.new/@vue/compiler-dom@14196
yarn add https://pkg.pr.new/@vue/compiler-dom@14196.tgz

@vue/compiler-sfc

pnpm add https://pkg.pr.new/@vue/compiler-sfc@14196
npm i https://pkg.pr.new/@vue/compiler-sfc@14196
yarn add https://pkg.pr.new/@vue/compiler-sfc@14196.tgz

@vue/compiler-ssr

pnpm add https://pkg.pr.new/@vue/compiler-ssr@14196
npm i https://pkg.pr.new/@vue/compiler-ssr@14196
yarn add https://pkg.pr.new/@vue/compiler-ssr@14196.tgz

@vue/reactivity

pnpm add https://pkg.pr.new/@vue/reactivity@14196
npm i https://pkg.pr.new/@vue/reactivity@14196
yarn add https://pkg.pr.new/@vue/reactivity@14196.tgz

@vue/runtime-core

pnpm add https://pkg.pr.new/@vue/runtime-core@14196
npm i https://pkg.pr.new/@vue/runtime-core@14196
yarn add https://pkg.pr.new/@vue/runtime-core@14196.tgz

@vue/runtime-dom

pnpm add https://pkg.pr.new/@vue/runtime-dom@14196
npm i https://pkg.pr.new/@vue/runtime-dom@14196
yarn add https://pkg.pr.new/@vue/runtime-dom@14196.tgz

@vue/server-renderer

pnpm add https://pkg.pr.new/@vue/server-renderer@14196
npm i https://pkg.pr.new/@vue/server-renderer@14196
yarn add https://pkg.pr.new/@vue/server-renderer@14196.tgz

@vue/shared

pnpm add https://pkg.pr.new/@vue/shared@14196
npm i https://pkg.pr.new/@vue/shared@14196
yarn add https://pkg.pr.new/@vue/shared@14196.tgz

vue

pnpm add https://pkg.pr.new/vue@14196
npm i https://pkg.pr.new/vue@14196
yarn add https://pkg.pr.new/vue@14196.tgz

@vue/compat

pnpm add https://pkg.pr.new/@vue/compat@14196
npm i https://pkg.pr.new/@vue/compat@14196
yarn add https://pkg.pr.new/@vue/compat@14196.tgz

commit: 8d4ba6f

@edison1105
Copy link
Copy Markdown
Member

Thanks for the PR. LGTM.

I tried to create a test for that, but it seems as if js-dom's focus api is not entirely consistent. Reproducing the step where the displayed characters in the input still contained the spaces but were gone on refocus was not possible. But If someone has a flash of inspiration, let me know or feel free to contribute.

Try adding an e2e test in packages/vue/__tests__/e2e/vModel.spec.ts

@edison1105 edison1105 added scope: v-model 🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. need test The PR has missing test cases. labels Dec 15, 2025
@edison1105 edison1105 changed the title fix(v-model.trim): correct the activeElement discovery for shadow DOM fix(runtime-dom): handle activeElement check in Shadow DOM for v-model Dec 18, 2025
@edison1105 edison1105 removed the need test The PR has missing test cases. label Mar 25, 2026
@edison1105
Copy link
Copy Markdown
Member

/ecosystem-ci run

@edison1105 edison1105 added the ready to merge The PR is ready to be merged. label Mar 25, 2026
@vuejs vuejs deleted a comment from edison1105 Mar 25, 2026
@vue-bot
Copy link
Copy Markdown
Contributor

vue-bot commented Mar 25, 2026

📝 Ran ecosystem CI: Open

suite result latest scheduled
radix-vue success success
vite-plugin-vue success success
test-utils success success
router success success
pinia success success
quasar failure success
primevue success success
vue-macros success success
language-tools failure failure
vant success success
vitepress success success
vue-simple-compiler success success
vueuse success success
vue-i18n success success
vuetify success success
nuxt success success

@edison1105 edison1105 merged commit 959ded2 into vuejs:main Mar 25, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. ready to merge The PR is ready to be merged. scope: v-model

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants